// Copyright 2025 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package testrepo is a simple way to manage a git repo as a testdata target
// embedded inside another git repo.
//
// This works by storing the git repo as a set of patches (generated by `git
// format-patch`) and a simple mechanism for deterministically reconstituting
// a git repo from these patches.
//
// Usage:
//
//	$ mkdir ./path/to/testdata/
//	$ cd ./path/to/testdata
//	$ go run go.chromium.org/luci/common/git/testrepo/manage_patchrepo init
//	$ ./path/to/testdata/manage_patchrepo.sh to-repo -overwrite
//	$ <interact with ./path/to/testdata/testgit_repo like normal git repo>
//	$ ./path/to/testdata/manage_patchrepo.sh to-patches
//	$ git commit ./path/to/testdata
//
// Then in your test, use:
//
//	repo, err := testrepo.ToGit(ctx, "./path/to/testdata", t.TempDir(), false)
//	assert.NoErr(t, err)
//	// repo is a self-cleaning path to a git repo on disk
//
// NOTE: The commit IDs won't be stable until you do to-patches and to-repo
// again.
//
// TODO: Support multiple branches
// TODO: Support merges?
package testrepo

import (
	"cmp"
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"time"

	"go.chromium.org/luci/common/exec"
)

const (
	TestgitRepoDir = "testgit_repo"
	PatchesDir     = "patches"
	UserName       = "Exemplar Exemplaris"
	UserEmail      = "exemplar@example.com"
)

var CommitterDate = time.Date(2024, time.April, 19, 13, 45, 54, 31, time.UTC)

type Repo struct {
	// Path to the directory (usually a subdirectory of a Go `testdata` directory)
	// which will contain:
	//   * .gitignore (for `/testgit_repo`)
	//   * patches (containing NNNNN-name.patch files)
	//   * testgit_repo (the reconstituted repo)
	//   * manage_patches.sh (a shell script to interface with the manage_patches
	//   Go command for developers).
	Basedir string
}

func childFiles(path string) ([]os.DirEntry, error) {
	f, err := os.Open(path)
	defer f.Close()
	if err != nil {
		if os.IsNotExist(err) {
			err = os.MkdirAll(path, 0777)
		}
		return nil, err
	}
	return f.ReadDir(-1)
}

func wipe(base string, entries []os.DirEntry) error {
	for _, entry := range entries {
		targ := filepath.Join(base, entry.Name())
		if err := os.RemoveAll(targ); err != nil {
			return fmt.Errorf("while removing %q: %w", targ, err)
		}
	}
	return nil
}

func git(ctx context.Context, cwd string, args ...string) *exec.Cmd {
	cmd := exec.CommandContext(ctx, "git", args...)
	cmd.Dir = cwd
	return cmd
}

type patchReader struct {
	base    string
	patches []os.DirEntry
	cur     *os.File
}

var _ io.Reader = (*patchReader)(nil)

func (p *patchReader) Read(buf []byte) (n int, err error) {
	for len(p.patches) > 0 || p.cur != nil {
		if p.cur == nil {
			p.cur, err = os.Open(filepath.Join(p.base, p.patches[0].Name()))
			if err != nil {
				return
			}
			p.patches = p.patches[1:]
		}

		var newN int
		newN, err = p.cur.Read(buf)
		n += newN
		if err == nil {
			return
		} else if err == io.EOF {
			p.cur.Close()
			p.cur = nil
		}
	}
	err = io.EOF
	return
}

func (p *patchReader) Close() error {
	if p.cur != nil {
		return p.cur.Close()
	}
	return nil
}

// ToGit will prepare a git repo at `destPath` (using baseDir/"testgit_repo"
// if destPath is "") using the patches in the director baseDir/"patches" (if
// any).
//
// If the destination already exists and is non-empty, and overwrite is false,
// returns an error.
//
// If the patch dir does not exist, it will be created without any content.
//
// For use in tests, it's recommended to use t.TempDir() as the argument for
// destPath to get automatic cleanup and not contaminate the checkout.
func ToGit(ctx context.Context, baseDir, destPath string, overwrite bool) (path string, err error) {
	if destPath == "" {
		destPath = filepath.Join(baseDir, TestgitRepoDir)
	}
	children, err := childFiles(destPath)
	if err != nil {
		return
	}
	if len(children) > 0 {
		if !overwrite {
			return "", fmt.Errorf("destPath(%q) overwrite(false): %w", destPath, os.ErrExist)
		}
		if err = wipe(destPath, children); err != nil {
			return "", err
		}
	}
	// directory exists and is empty
	if err = git(ctx, destPath, "init", "-b", "main").Run(); err == nil {
		if err = git(ctx, destPath, "config", "user.email", UserEmail).Run(); err == nil {
			if err = git(ctx, destPath, "config", "user.name", UserName).Run(); err == nil {
				var patches []os.DirEntry
				patchDir := filepath.Join(baseDir, PatchesDir)
				if patches, err = childFiles(patchDir); err == nil {
					slices.SortFunc(patches, func(a, b os.DirEntry) int { return cmp.Compare(a.Name(), b.Name()) })
					reader := &patchReader{patchDir, patches, nil}
					defer reader.Close()

					cmd := git(ctx, destPath, "am")
					cmd.Stdin = reader
					cmd.Env = append(cmd.Environ(), fmt.Sprintf(
						"GIT_COMMITTER_DATE=%s", CommitterDate.Format("01/02/2006 03:04p -0700")))
					err = cmd.Run()
				}
			}
		}
	}

	return destPath, err
}

// ToPatches will format the entire git history from the current checked out ref
// at repoPath, and write this history as patchfiles in baseDir/patches.
//
// This will delete the entire existing patch directory, if any.
func ToPatches(ctx context.Context, baseDir, repoPath string) error {
	patchDir := filepath.Join(baseDir, PatchesDir)
	patches, err := childFiles(patchDir)
	if err != nil {
		return err
	}
	if len(patches) > 0 {
		if err = wipe(patchDir, patches); err != nil {
			return err
		}
	}

	val, err := git(ctx, repoPath, "config", "user.email").Output()
	if err != nil || strings.TrimSpace(string(val)) != UserEmail {
		return fmt.Errorf("unexpected user.email(%q): %w", val, err)
	}

	val, err = git(ctx, repoPath, "config", "user.name").Output()
	if err != nil || strings.TrimSpace(string(val)) != UserName {
		return fmt.Errorf("unexpected user.name(%q): %w", val, err)
	}

	// Format all patches from HEAD back to the start of history.
	//
	// Put them in the patches directory (next to this script).
	//
	// Set the signature to "" to avoid non-determinism from the default signature
	// (which has the output of `git version`).
	return git(ctx, repoPath, "format-patch", "--root", "HEAD", "-o", patchDir, "--signature", "", "--no-numbered").Run()
}
